Перейти к основному содержимому

4.09. Dependency Injection

Разработчику Архитектору Инженеру

Dependency Injection (DI)

DI - это способ реализации DIP. Кстати, поэтому к Dependency Inversion и лучше добавить Principle, чтобы не путать их.

Dependency Inversion - это принцип проектирования, а Dependency Injection - паттерн проектирования. DIP говорит «что делать», DI - «как делать».

Dependency Injection решает вопрос - как передавать зависимости, а не создавать их внутри. Существует несколько видов внедрения зависимости:

  1. Constructor Injection (через конструктор).
  2. Setter Injection (через сеттер).
  3. Field Injection (через поле).
  4. Property Injection (через свойства).
  5. Method Injection (через метод).

Как раз способ, показанный со Switch - это конструктор. В классе нужно написать поле с типом данных абстракции, и в конструкторе добавить аргумент с сопоставлением поля и переменной. Как это работает? Давайте на примере C#.

public class UserService
{
private readonly IUserRepository _repo;

public UserService(IUserRepository repo)
{
_repo = repo;
}
}

Внимание - обратите внимание на курсив и жирный шрифт в коде. В данном случае у нас есть класс UserService, которому создаётся поле _repo (нижнее подчёркивание добавлено для обозначения именно поля). У _repo, как можно заметить, тип данных - IUserRepository - некий интерфейс, который существует в коде.

Затем создаётся конструктор, который получает аргумент с типом данных IUserRepository и записывает в переменную repo. В теле метода уже указывается, что поле _repo будет иметь значение того самого аргумента - переменной repo. Поэтому _repo = repo. Это пример в C#, в .NET есть встроенный DI-контейнер. В Java используется DI, к примеру, в Spring:

@Service
public class UserService {
private final EmailService emailService;

public UserService(EmailService emailService) { // DI через конструктор
this.emailService = emailService;
}
}

Как можно заметить - сходство очень велико. Spring в данном случае автоматически решает работу сервиса.

Python, JavaScript (TypeScript, Node.js, Angular) специфичны, но тоже имеют такие возможности:

Python:

class UserService:
def __init__(self, email_service: EmailService):
self.email_service = email_service

JS:

class UserService {
constructor(private emailService: EmailService) {} // DI через конструктор
}

DI-контейнер (также называют IoC-контейнер) - это фреймворк или механизм, который автоматически создаёт и внедряет зависимости. Он выполняет следующие задачи:

  • Регистрирует типы (к примеру, IEmailService и его наследник SmptEmailService);
  • Создаёт объекты (с учётом зависимостей);
  • Разрешает зависимости (внедряет нужные объекты);
  • Управляет жизненным циклом (singleton, transient, scoped).

Пример, как мы упомянули с IEmailService и SmtpEmailService:

container.register<IEmailService, SmtpEmailService>();
container.register<UserService>();

UserService userService = container.resolve<UserService>();
// → контейнер сам создаст SmtpEmailService и передаст в UserService

Так, благодаря DI, мы получаем возможность подмены реальных сервисов на тестируемые элементы (моки, стабы), можем менять реализации, снижаем связанность (классы не зависят от конкретных реализаций), можем повторно использовать компоненты в разных контекстах (вспомним Switch с разными девайсами), и получаем централизованное управление всеми зависимостями в DI-контейнере.

interface EmailService {
void send(String msg);
}

class SmtpEmailService implements EmailService { ... }
class MockEmailService implements EmailService { ... } // для тестов

class UserService {
private EmailService emailService;

public UserService(EmailService emailService) { // DI
this.emailService = emailService;
}

public void register(User user) {
// ... логика
emailService.send("Welcome!");
}
}

Но мы разбираем, по сути, только внедрение через конструктор. А как же с другими? Давайте по порядку.

Setter Injection (внедрение через сеттер) подразумевает, что зависимость внедряется после создания объекта через сеттер-метод. Это используется, когда зависимость не обязательна (опциональна), когда объект может существовать без зависимости, но позже её можно подключить, и встречается в legacy-кодах или фреймворках, где конструктор уже занят.

public class UserService {
private EmailService emailService;

// Сеттер для внедрения зависимости
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}

public void register(User user) {
emailService.send("Welcome!"); // используем
}
}

В Java (Spring) есть такое понятие - bean, которое позволяет записать связь UserService с свойством emailService, в результате чего Spring вызывает setEmailService() автоматически:

<bean id="userService" class="UserService">
<property name="emailService" ref="emailService"/>
</bean>

C# выглядит похожим образом

public class UserService
{
private IEmailService _emailService;

public void SetEmailService(IEmailService emailService) // DI через сеттер
{
_emailService = emailService;
}
}

На первый взгляд, вариант DI через сеттер ОЧЕНЬ похож на вариант конструктора, но тут есть отличия. Давайте наглядно:

КритерийDI через конструкторDI через сеттер
Механизм внедренияИспользуется конструктор класса.Используется отдельный метод (сеттер).
Обязательность зависимостиОбязательная. Класс не может быть создан без предоставления зависимости.Опциональная. Объект можно создать без зависимости, но она потребуется при вызове соответствующего метода.
ИзменяемостьИммутабельная. Зависимость устанавливается один раз при создании объекта и не может быть изменена (особенно если поле readonly).Мутабельная. Зависимость может быть заменена в любой момент времени после создания объекта.
Гарантия инициализацииВысокая. Компилятор гарантирует, что зависимость будет передана. Отсутствие зависимости приведёт к ошибке на этапе компиляции или отказу в создании экземпляра.Низкая. Нет гарантий, что сеттер будет вызван. Если зависимость не установлена, это может привести к NullReferenceException во время выполнения.
Явность требованийВысокая. Все необходимые зависимости явно указаны в сигнатуре конструктора. Сразу понятно, что требуется для работы класса.Низкая. Требования к зависимостям не очевидны из сигнатуры конструктора. Необходимо изучать документацию или код, чтобы узнать, какой сеттер нужно вызвать.
Основное применениеКогда зависимость является критичной для функционирования класса и должна быть определена с момента его создания. Предпочтительный способ внедрения зависимостей.Когда зависимость является опциональной, может быть добавлена позже или должна иметь возможность быть переконфигурированной во время жизненного цикла объекта.

Property Injection (внедрение через свойства) часто бывает как синоним Setter Injection), особенно в .NET. Технически, в .NET и некоторых DI-контейнерах «свойство» (property) = public setter. То есть, DI-контейнер автоматически может устанавливать свойства, если у них есть сеттер:

public class UserService
{
public IEmailService EmailService { get; set; } // автоматически внедряется
}

Контейнер, к слову выглядеть будет так:

services.AddTransient<UserService>();

Если EmailService зарегистрирован, контейнер сам установит свойство, если оно доступно для записи. Это удобно для необязательных сервисов и предусматривает автоматизацию. Минусы здесь те же, что и у сеттера - объект может быть не полностью сконфигурирован и менее предсказуемый. Словом, это некий «частный случай» инъекции через сеттер, но реализованный «магически» контейнером.

Внедрение через поле (Field Injection) подразумевает, что зависимость напрямую внедряется в поле объекта, минуя конструктор и сеттеры. Часто используется с аннотациями и декораторами:

@Service
public class UserService {
@Autowired
private EmailService emailService; // внедряется в поле!
}

Но Field Injection считается антипаттерном и рекомендуется такой способ избегать. При этом невозможно протестировать без DI-контейнера, зависимости не видны (согласитесь - это уже не так очевидно?), нельзя сделать поле final / readonly.

Method Injection (внедрение через метод) подразумевает, что зависимость передаётся в метод, а не хранится в объекте. Используется, когда зависимость нужна только для одного вызова, меняется от вызова к вызову или когда хочется избежать хранения состояния.

public class UserService {
public void register(User user, EmailService emailService) { // зависимость в методе
// ... логика
emailService.send("Welcome!");
}
}

Логика здесь слегка размазывается, а вызывающий код должен знать, какую реализацию передавать. Это не подходит, если зависимость используется во множестве методов. Это не совсем DI в своём классическом ООП-смысле, скорее передача параметра, но формально, это вид инъекции зависимости в метод.

Итого, если рассмотреть что-то в конечном смысле, лучше всегда и по умолчанию использовать именно конструктор, а инъекция через поле - плохая практика. В функциональных или простых сценариях можно использовать внедрение через метод.